Перейти к основному содержимому

5.22. Типы данных

Разработчику Архитектору

Типы данных

Переменные как именованные места хранения

Переменная в Dart — это именованная область памяти, предназначенная для хранения значения. Каждая переменная имеет имя, тип и значение. Имя служит для обращения к переменной в коде, тип определяет природу хранимых данных, а значение — это конкретный экземпляр данных, который в данный момент находится в переменной. Объявление переменной в Dart всегда начинается с указания её типа или ключевого слова var, если тип выводится автоматически. Например, запись int age = 25; создаёт переменную с именем age, типом int (целое число) и присваивает ей значение 25. Такой подход делает код читаемым и предсказуемым: любой разработчик, читающий эту строку, сразу понимает, что переменная age предназначена для хранения целочисленного значения, связанного с возрастом.

Dart поддерживает механизм вывода типов, что позволяет использовать ключевое слово var вместо явного указания типа. При этом компилятор сам определяет тип на основе значения, присвоенного переменной при инициализации. Например, var name = 'Alice'; создаёт переменную name типа String. Важно отметить, что даже при использовании var тип переменной фиксируется на этапе компиляции и не может быть изменён в дальнейшем. Это отличает Dart от динамически типизированных языков, где одна и та же переменная может содержать значения разных типов в разные моменты времени. В Dart после объявления переменной её тип остаётся неизменным на протяжении всего жизненного цикла.

Неизменяемость и ключевые слова const и final

Помимо обычных переменных, Dart предоставляет два механизма для создания неизменяемых значений: ключевые слова final и const. Переменная, объявленная с помощью final, может быть инициализирована только один раз — либо при объявлении, либо в конструкторе объекта, если речь идёт о поле класса. После инициализации значение такой переменной нельзя изменить. Это особенно полезно для хранения данных, которые не должны меняться в процессе выполнения программы, например, идентификатор пользователя или конфигурационные параметры. Пример: final String userId = 'U12345';.

Ключевое слово const используется для объявления компиляционных констант — значений, которые известны уже на этапе компиляции и неизменны во всём приложении. Константы создаются один раз и разделяются между всеми частями программы, что делает их эффективными с точки зрения использования памяти. Все значения примитивных типов, литералы коллекций и объекты, помеченные как const, могут быть объявлены с этим ключевым словом. Например, const double pi = 3.14159; или const List<String> weekdays = ['Понедельник', 'Вторник', 'Среда'];. Важно понимать, что const применяется не только к переменной, но и ко всему выражению, которое создаёт значение. Это означает, что объект, созданный с const, действительно является неизменяемым и каноническим — любые два одинаковых const-объекта будут ссылаться на одну и ту же область памяти.

Встроенные типы данных

Dart предоставляет богатый набор встроенных типов данных, каждый из которых решает определённую задачу. Эти типы можно разделить на несколько категорий: числовые, текстовые, логические, коллекции и специальные типы.

Числовые типы: int и double

Числовые данные в Dart представлены двумя основными типами: int и double. Тип int используется для хранения целых чисел — положительных, отрицательных и нуля. Диапазон значений int зависит от платформы: в среде Dart VM (например, при запуске на сервере или в командной строке) он составляет 64 бита, что позволяет хранить числа от -2⁶³ до 2⁶³ - 1. В JavaScript-среде (например, при компиляции в веб) int ограничен 53-битным диапазоном, совместимым с числовым типом JavaScript. Это ограничение важно учитывать при разработке кроссплатформенных приложений.

Тип double предназначен для хранения чисел с плавающей запятой и соответствует стандарту IEEE 754 с двойной точностью (64-битные числа). Он позволяет представлять как очень маленькие, так и очень большие дробные значения, включая специальные значения, такие как бесконечность (double.infinity) и нечисловое значение (double.nan). Операции с числами в Dart интуитивно понятны: сложение, вычитание, умножение, деление и другие арифметические действия выполняются с помощью стандартных операторов. Язык также предоставляет богатую библиотеку математических функций через объект Math.

Текстовый тип: String

Текст в Dart представлен типом String. Этот тип инкапсулирует последовательность символов Unicode и поддерживает все современные возможности работы с текстом. Строки в Dart неизменяемы — любая операция, которая кажется изменением строки, на самом деле создаёт новую строку. Dart предлагает несколько способов создания строк: с помощью одинарных или двойных кавычек, многострочных строк с тройными кавычками, а также интерполяцию значений с помощью символа $. Интерполяция позволяет встраивать выражения прямо в строку: 'Привет, ${name}! Тебе ${age} лет.'. Это делает формирование текстовых сообщений удобным и читаемым.

Строки в Dart поддерживают множество методов для анализа и преобразования: поиск подстрок, замена, разбиение на части, приведение к верхнему или нижнему регистру и многое другое. Все эти операции реализованы как методы класса String, что соответствует объектно-ориентированной природе языка.

Логический тип: bool

Логический тип данных в Dart называется bool и может принимать только два значения: true и false. Этот тип используется в условиях, циклах и других конструкциях управления потоком выполнения программы. В отличие от некоторых других языков, Dart не допускает неявного преобразования других типов в логический. Например, нельзя использовать целое число или строку в условии if — компилятор потребует явного выражения типа bool. Это требование повышает строгость и предсказуемость кода, исключая неочевидные ошибки, связанные с неявными приведениями.


Коллекции: List, Set и Map

Dart предоставляет три основных встроенных типа коллекций: List, Set и Map. Эти типы позволяют группировать значения одного или разных типов, организуя их в структуры, удобные для хранения, перебора и модификации.

List — это упорядоченная последовательность элементов, каждый из которых имеет свой индекс, начиная с нуля. Списки в Dart изменяемы по умолчанию, если не объявлены как const или final. Они поддерживают операции добавления, удаления, замены и доступа по индексу. Например, List<String> names = ['Анна', 'Борис', 'Вера']; создаёт список строк, где порядок элементов имеет значение. Dart также поддерживает литералы списков, что делает создание коллекций лаконичным и выразительным. Списки могут быть однородными (все элементы одного типа) или гетерогенными (элементы разных типов), хотя в большинстве случаев предпочтение отдаётся однородным коллекциям ради безопасности типов.

Set — это неупорядоченная коллекция уникальных элементов. Главное свойство множества заключается в том, что оно автоматически исключает дубликаты. Если попытаться добавить в Set уже существующий элемент, коллекция останется без изменений. Это особенно полезно при работе с данными, где важна уникальность: например, при сборе идентификаторов пользователей или фильтрации повторяющихся значений. Объявление множества выглядит так: Set<int> uniqueIds = {101, 102, 103};. Как и списки, множества поддерживают стандартные операции: объединение, пересечение, разность и проверку на принадлежность.

Map — это ассоциативная коллекция, состоящая из пар «ключ — значение». Каждый ключ в карте должен быть уникальным, но значения могут повторяться. Карта позволяет быстро находить значение по ключу, что делает её идеальной для хранения конфигураций, кэшей или любых структур, где требуется прямой доступ к данным по идентификатору. Пример объявления: Map<String, int> ages = {'Анна': 28, 'Борис': 34};. Dart поддерживает как изменяемые, так и неизменяемые карты, а также предоставляет богатый набор методов для работы с ними: получение всех ключей, всех значений, проверка наличия ключа и многое другое.

Все три типа коллекций реализованы как полноценные классы в стандартной библиотеке Dart, что означает, что они обладают собственными методами и свойствами. Это соответствует объектно-ориентированной природе языка и делает работу с данными интуитивной и последовательной.

Тип dynamic и гибкость выполнения

Несмотря на то что Dart — язык со статической типизацией, он предоставляет возможность использовать тип dynamic для переменных, чей тип неизвестен на этапе компиляции. Переменная типа dynamic может содержать значение любого типа, и компилятор не будет выполнять проверку операций над ней до момента выполнения программы. Это даёт определённую гибкость, особенно при взаимодействии с внешними системами, такими как JSON-API, где структура данных может быть заранее неизвестна.

Однако использование dynamic снижает безопасность типов и увеличивает риск ошибок времени выполнения. Поэтому его рекомендуется применять только в тех случаях, когда нет возможности определить точный тип заранее. В большинстве сценариев лучше использовать строго типизированные переменные или механизмы, такие как дженерики и интерфейсы, которые сохраняют предсказуемость кода.

Null-безопасность и тип Object?

Одной из ключевых особенностей современного Dart является система null-безопасности, введённая в версии 2.12. Эта система гарантирует, что переменная не может содержать значение null, если её тип явно не допускает этого. По умолчанию все типы в Dart являются не-nullable — то есть переменная типа String всегда должна содержать строку, а не null. Чтобы разрешить хранение null, необходимо явно указать nullable-тип, добавив знак вопроса: String?.

Это требование распространяется на все типы, включая пользовательские классы. Оно заставляет разработчика явно продумывать, где допустимо отсутствие значения, и обрабатывать такие случаи в коде. Например, функция, которая может вернуть null при отсутствии результата, должна быть объявлена как возвращающая String?, а вызывающий код обязан проверить результат перед использованием.

Тип Object? представляет собой корень всей иерархии типов в Dart. Он может содержать любое значение, включая null. Все остальные типы, включая примитивы и коллекции, являются подтипами Object. Это позволяет писать универсальные функции и структуры данных, но на практике прямое использование Object? встречается редко, поскольку Dart поощряет конкретизацию типов для повышения читаемости и надёжности.

Пользовательские типы и расширение системы типов

Помимо встроенных типов, Dart позволяет создавать собственные типы данных с помощью классов, перечислений и абстракций. Класс определяет структуру объекта, его свойства и поведение. Каждый экземпляр класса становится значением нового типа. Например, можно создать класс Person с полями name и age, и тогда переменная типа Person будет хранить объект, представляющий конкретного человека.

Перечисления (enum) используются для определения ограниченного набора именованных значений. Они полезны, когда переменная должна принимать одно из нескольких заранее известных состояний: например, enum Status { active, inactive, pending }. Использование перечислений делает код более выразительным и защищает от ошибок, связанных с передачей недопустимых значений.

Dart также поддерживает расширения типов (extension), которые позволяют добавлять новые методы к существующим классам без изменения их исходного кода. Это особенно удобно при работе со стандартными типами, такими как String или int, когда требуется дополнительная функциональность, специфичная для конкретного проекта.


Дженерики: параметризация типов для гибкости и безопасности

Dart поддерживает дженерики — механизм, позволяющий определять классы, интерфейсы и методы с параметрами типа. Это означает, что одна и та же структура может работать с разными типами данных, сохраняя при этом строгую проверку типов. Коллекции List, Set и Map, упомянутые ранее, являются яркими примерами использования дженериков. Запись List<String> указывает, что список предназначен исключительно для хранения строк, а попытка поместить в него число вызовет ошибку на этапе компиляции.

Дженерики повышают переиспользуемость кода без потери безопасности. Разработчик может написать универсальный класс контейнера, не привязываясь к конкретному типу данных, и при этом гарантировать, что все операции внутри этого контейнера будут корректны для любого переданного типа. Например, можно создать класс Box<T>, где T — параметр типа. При использовании Box<int> все методы этого класса будут работать с целыми числами, а при использовании Box<Person> — с объектами типа Person. Компилятор обеспечивает, что смешивание типов невозможно, даже если логика класса написана один раз.

Параметры типа могут быть ограничены с помощью ключевого слова extends, чтобы гарантировать наличие определённых свойств или методов у передаваемого типа. Это позволяет писать более осмысленные и безопасные обобщённые алгоритмы. Например, функция, принимающая только объекты, реализующие интерфейс Comparable, может быть объявлена как <T extends Comparable<T>>. Такой подход сочетает гибкость дженериков с гарантией наличия необходимого поведения у используемых типов.

Роль Object и иерархия типов

В Dart вся система типов построена вокруг корневого класса Object. Каждый тип, будь то примитивный (int, String) или пользовательский (Person, Car), является подтипом Object. Это означает, что любой объект в Dart наследует базовый набор методов, таких как toString(), hashCode и runtimeType. Эти методы обеспечивают стандартное поведение для преобразования объекта в строку, вычисления его хэш-кода и получения информации о его типе во время выполнения.

Класс Object? расширяет эту иерархию, включая в неё значение null. Таким образом, Object? становится самым общим типом в системе, способным принять любое значение, включая отсутствие значения. Эта модель интегрирована в систему null-безопасности и позволяет единообразно обрабатывать все возможные случаи в программе.

Иерархия типов в Dart строгая и последовательная. Подтип всегда может быть использован там, где ожидается его надтип — это принцип подстановки Лисков, реализованный на уровне языка. Он лежит в основе полиморфизма и позволяет писать гибкие, расширяемые системы, где поведение объекта определяется его реальным типом, а не статическим объявлением переменной.

Преобразование типов и проверка совместимости

Dart предоставляет механизмы для безопасного преобразования значений между типами. Оператор is позволяет проверить, принадлежит ли объект определённому типу. Например, выражение if (item is String) гарантирует, что внутри блока if переменная item будет рассматриваться как String, и к ней можно применять все методы этого типа. Это называется сужением типа и является важной частью работы с полиморфными данными.

Оператор as используется для явного приведения типа. Он применяется, когда разработчик уверен, что значение имеет нужный тип, но компилятор не может это определить автоматически. Например, final str = obj as String; преобразует obj в строку. Если фактический тип obj несовместим со строкой, возникнет ошибка времени выполнения. Поэтому использование as требует осторожности, и предпочтение обычно отдаётся проверке через is.

Неявные преобразования между числовыми типами в Dart отсутствуют. Нельзя присвоить значение типа int переменной типа double без явного указания. Это требование предотвращает скрытые потери точности и делает арифметические операции более предсказуемыми. Для преобразования используются методы, такие как toDouble() или toInt(), которые чётко выражают намерение разработчика.

Практические рекомендации по работе с типами

Эффективное использование типовой системы Dart начинается с явного указания типов при объявлении переменных, параметров функций и возвращаемых значений. Это делает код самодокументируемым и помогает другим разработчикам быстрее понимать его логику. Даже если компилятор способен вывести тип автоматически, явное указание часто повышает читаемость.

Избегайте избыточного использования dynamic и Object?. Эти типы следует применять только тогда, когда структура данных действительно неизвестна заранее, например, при парсинге JSON из внешнего источника. В остальных случаях стремитесь к максимально конкретным типам — это снижает количество ошибок и упрощает рефакторинг.

Используйте final по умолчанию для переменных, которые не предполагают изменения после инициализации. Это уменьшает мутабельность состояния программы и делает её поведение более предсказуемым. Применяйте const для значений, которые известны на этапе компиляции и неизменны во всём приложении — это улучшает производительность за счёт повторного использования объектов.